# This file belongs to CLIParser
#
# Copyright (C) 2007 Pieter Edelman (p _dot_ edelman AT gmx _dot_ net)
#
# CLIParser is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 2 only.
#
# CLIParser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
#

# FIXME: Emit warnings at the approporiate time

import sys, types, re

class Option:
  """ An Option object, which makes up the list of options for the CLIParser. """
  def __init__(self, short_form = None, long_form = None, type = "string", var = None, metavar = None, bool_action = None, action = "store", default = None, nargs = 1, help = "", callback = None):
    """ Initialize the Option object. """
    
    # Perform the necessary checks and adjustments for the different sisutations
    if (short_form):
      if (short_form[0] != "-") and (not short_form[1].isalpha()):
        raise "Short form should be a dash followed by a letter!"
      else:
        short_form = short_form[1]
    if (long_form):
      if (not re.match("--[A-Za-z][A-Za-z-]*$", long_form)):
        raise "Long form should be two dashes followed by a combination of letters and dashes!"
      else:
        long_form = long_form[2:]
    if (action == "print"):
      if (not callback):
        raise "A callback is required for a print action!"
      else:
        self.callback = callback
      type  = None
      nargs = 0
      var   = None
    elif (action not in ["store", "append"]):
      raise "action needs to be either \"store\", \"append\", or \"print\"!"
    if (nargs > 1):
      if (metavar):
        if (len(metavar) != nargs):
          raise "Metavar should be a tuple with same length as nargs for option -%s/--%s!" % (short_form, long_form)
        else:
          metavar = tuple(metavar)
      if (default):
        if (len(default) != nargs):
          raise "Default should be a tuple with same length as nargs for option -%s/--%s!" % (short_form, long_form)
    if (type == "bool"):
      if (not bool_action):
        raise "When specifying a bool, you need to set bool_action."
      if (nargs != 1):
        raise "When specifying a bool, you can have only one argument."
    if ((type in ["string", "int", "float", "bool"]) and (not var)):
      raise "I need a variable for option (-%s/--%s)!" % (short_form, long_form)
      
    # Set all the vars
    self.short       = short_form
    self.long        = long_form
    self.type        = type
    self.var         = var
    self.default     = default
    self.nargs       = nargs
    self.action      = action
    self.bool_action = bool_action
    self.metavar     = metavar
    self.help        = help

  def getDefault(self):
    """ Returns the default value for the current option, adjusted to nargs
        value if needed. """
        
    if (self.action == "append"):
      if (self.default != None):
        return [self.default]
      else:
        return []
    else:
      if (self.default != None):
        return self.default
      else:
        if (self.nargs == 1):
          return None
        else:
          return self.nargs * [None]
          
  def getHelp(self):
    """ Returns the help string for switches and help message. """
    
    # Construct the switch help messages
    short_help = None
    long_help  = None

    if (self.short):
      short_help = "-%s" % self.short
    if (self.long):
      long_help = "--%s" % self.long

    # Append the metavar(s)
    if (self.action != "print"):
      if (self.type in ["int", "float", "string"]):
        if (self.metavar):
          metavar = (self.nargs * "<%s> ") % self.metavar
        else:
          metavar = (self.nargs * "<%s> ") % tuple(self.nargs * [self.type])
        metavar = metavar[:-1]
        
        if (short_help):
          short_help += " " + metavar
        if (long_help):
          long_help += "=" + metavar
      elif (self.type == "bool"):
        if (short_help):
          short_help += " (y/n)"
        if (long_help):
          long_help += "(=y/n)"
    
    if ((short_help) and (long_help)):
      switch_help = short_help + ", " + long_help
    elif (short_help):
      switch_help = short_help
    else:
      switch_help = long_help
      
    help_msg = re.sub("(?<!%)%default", str(self.default), self.help)
    help_msg = help_msg.replace("%%default", "%default")
        
    return [switch_help, help_msg]
      
class Conflict:
  """ This class provides the tools for chacking and handling conflicts between
      options the user sets. It is a bit hackish, with ***kwargs en evals. """ 
  def __init__(self, logger, rule, message = "", action = "exit", **kwargs):
    """ Initialize the conflict. See CLIParser.addConflict for a full
        description. """
        
    self.logger = logger
    
    # Do the necessary checks and actions for the different possible actions.
    if (action == "exit"):
      callback = self.printHelpAndExit
      if (not "exit_code" in kwargs):
        kwargs["exit_code"] = 1
      self.help_getter = kwargs["help_getter"]
      del kwargs["help_getter"]
    elif (action == "setvalues"):
      callback = self.setValues
      if (not "values" in kwargs):
        raise "You need to specify a dict with variables and their desired value"
    elif (action == "callback"):
      if "callback" in kwargs:
        callback = kwargs["callback"]
        del kwargs["callback"]
      else:
        raise "No callback specified!"
    else:
      raise "Unknow action %s!" % action
      
    # Set the parameters
    self.rule     = rule
    self.message  = message
    self.callback = callback
    self.kwargs   = kwargs
    
  def evaluate(self, values):
    """ Evaluate the rule with values as input for the local values. If the rule
        is true, call the callback. """
    
    if eval(self.rule, globals(), values):
      self.callback(self.message, **self.kwargs)
      
  def setValues(self, message, destination, values):
    """ Helper function for the "setvalues" action. It displayes the message,
        and sets the values of the keys in dict "destionation" to the values
        specified in the dict "values". """
        
    self.logger.message(message)
    
    for var in values:
      destination[var] = values[var]
      
  def printHelpAndExit(self, message, exit_code):
    """ Helper function for the "exit" action. It displays the help message and
        the error message and the exits with the specified exit code. """
        
    self.logger.message(self.help_getter())
    self.logger.error(message)
    sys.exit(exit_code)
    
class CLIParser:
  """ An alternative to Pythons built-in optparse. The CLIParser class
      implements a subset of that module, but expands some of the features.
      Booleans in CLIParser can have an optional "yes" or "no" argument, and the
      help message can be more closely controlled by adding additions help rules
      between the option definitions.
      It is also possible to specify conflicting options and associate them with
      an action."""
      
  def __init__(self, logger, usage = None, description = None, version = None):
    """ Initialize the CLIParser with the usage string, description sting, and
        version string. Like with optparse, usage and description may contain
        the %prog directive, which is expanded to the current program name. """

    self.logger = logger
    
    # The internal list of Option objects
    self.options = []
    
    # The internal list of help rules, which can consist of Option objects and
    # strings intermixed.
    self.help_rules = []
    
    # The values is a dict with variable names as keys and their values as, well
    # their values. The arguments are everythin that doesn't belong to option.
    self.values     = {}
    self.arguments  = []
    self.conflicts  = []
    
    # There are two special options, used for displaying the help message and
    # version number. Users can control the help output with them.
    self.help_switch = Option("-h", "--help", action = "print", callback = self.printHelp, help = "Show this help message and exit.")
    self.options.append(self.help_switch)
    if (version):
      self.version_switch = Option(long_form = "--version", action = "print", callback = self.printVersion, help = "Print the program's version number and exit")
      self.options.append(self.version_switch)
    
    if (usage):
      self.usage = usage
    else:
      self.usage = "%prog [OPTIONS]"
      
    self.description = description
    self.version     = version

  def addOption(self, *switches, **kwargs):
    """ Add an option to the parser. Switches may be the short and/or long form
        with the leading dashes. The keyword arguments are type, var,
        action, bool_action, default, nargs and help.
        - type may be float, int, string or bool. If bool is specified,
          bool_action needs also be set to True or False. This parameter
          specifies what to do it the switch is encountered without argument (in
          optparse you would use action="store_true" or "store_false".
        - var is the variable name to store the value to.
        - action may be "store" or "append".
        - default needs to be the default value, or a list of default values
          with nargs parts.
        - nargs is the required number of arguments.
        - help is the help string. Like with optparse, it can use the directive
          %default which will be expanded to the default value.
        """

    # Extract the short and long forms
    short_form = None
    long_form  = None
    
    for switch in switches:
      if (len(switch) == 2):
        short_form = switch
      elif (len(switch) > 2):
        long_form = switch

    # Create a new option with the necessary parameters
    new_opt = Option(short_form, long_form, **kwargs)
        
    # Check if we don't specify double options
    for option in self.options:
      if (option.short):
        if (option.short == new_opt.short):
          raise "Option -%s already exists!" % new_opt.short
      if (option.long):
        if (option.long == new_opt.long):
          raise "Option --%s already exists!" % new_opt.long
      if ((option.var == new_opt.var) and (option.action != new_opt.action)):
        raise "It is not possible to mix store and append actions with the same var!"
      
    # Add the option and the help rule to the lists
    self.options.append(new_opt)
    self.help_rules.append(self.options[-1])
    
  def parse(self, cl = None):
    """ Parse the comand line. This should be a list of options. If cl is not
        given, sys.argv[1:] is used. """

    # If no command line is given, uses sys.argv
    if (not cl):
      cl = sys.argv[1:]
   
    # Parse each option
    while (len(cl) > 0):
      user_opt = cl.pop(0)
      user_opt_parts = user_opt.split("=")
      found_opt = False
      args = []
      # If it looks like an option, test for it
      if (self.__isSwitch__(user_opt_parts[0])):
        # Iterate over all known options
        for test_opt in self.options:
          # Try to match the short form
          if (test_opt.short):
            if (("-" + test_opt.short) == user_opt[:2]):
              found_opt = test_opt
              # If there was an extra part after the first two chars, its an
              # argument
              if (len(user_opt) > 2):
                arg = self.__getArg__(test_opt, user_opt[2:])
                if (arg != None):
                  args.append(arg)
                else:
                  self.printAndExit("The argument specified to option %s wasn't valid!" % user_opt[:2], 1)
          # Try to match the long form
          if (test_opt.long):
            if (user_opt_parts[0] == ("--" + test_opt.long)):
              found_opt = test_opt
              # If there was a part after the "=" sign, it's an argument
              if (len(user_opt_parts) > 1):
                arg = self.__getArg__(test_opt, user_opt_parts[1])
                if (arg != None):
                  args.append(arg)
                else:
                  self.printAndExit("The argument specified to option %s wasn't valid!" % user_opt_parts[0], 1)
          
          if (found_opt):
            # If we have a print option, execute it 
            if (found_opt.action == "print"):
              found_opt.callback()
              
            # If we don't already have enough arguments, find some more
            if (len(args) < found_opt.nargs):
              for arg_num in range(found_opt.nargs - len(args)):
                if (len(cl) > 0):
                  if (self.__isSwitch__(cl[0])):
                    break
                  arg = self.__getArg__(test_opt, cl[0])
                  if (arg != None): # Explicit test for None, because False is a valid return
                    args.append(arg)
                    del cl[0]
                  
            # If we still don't have enough arguments, the user didn't specify
            # enough of the or we should fall back to a default, depending on
            # the type of option.
            if (len(args) < found_opt.nargs):
              if (test_opt.type in ["string", "float", "int"]):
                if (test_opt.nargs == 1):
                  self.printAndExit("You need to specify an argument of type %s for option %s!" % (found_opt.type, user_opt_parts[0]))
                else:
                  self.printAndExit("You need to specify %d arguments of type %s for option %s!" % (found_opt.nargs, user_opt.type, user_opt_parts[0]))
              elif (test_opt.type == "bool"):
                if (test_opt.bool_action):
                  args.append(True)
                else:
                  args.append(False)
                
            # Set the arguments to the internal values list
            if (found_opt.nargs == 1):
              args = args[0]
              
            if (found_opt.action == "store"):
              self.values[found_opt.var] = args
            elif (found_opt.action == "append"):
              if (found_opt.var not in self.values):
                self.values[found_opt.var] = [args]
              else:
                self.values[found_opt.var].append(args)
              
            # If we had a match, break out of the matching loop and move on to
            # the next user-specified argument.
            break
            
        # If at the end no match was found although it did look like one,
        # raise an error
        if not (found_opt):
          self.printAndExit("%s is not a recognized option" % user_opt_parts[0], 1)

      if (not found_opt):
        self.arguments.append(user_opt)
    
    # If no options and no arguments were given, display only the help and exit
    if ((len(self.values) == 0) and (len(self.arguments) == 0)):
      self.printAndExit(self.getHelp(), 1)
      
    # For all options which aren't set, set the default
    for option in self.options:
      if ((option.var) and (option.var not in self.values)):
        self.values[option.var] = option.getDefault()
      
    # Check for conflicts
    for conflict in self.conflicts:
      conflict.evaluate(self.values)
      
    return self.values, self.arguments
        
      
  def getHelp(self, term_width = None):
    """ Return the help message for the program formatted to the term width. """
    # FIXME: If the terminal width is not given, assume it's 80
    if (not term_width):
      term_width = 80
      
    # Keep track of whether we included the help and version switches in the
    # output (-h/--help and --version).
    help_switch    = False
    version_switch = False
    
    # Unpack each Options help into a tuple of short switch + long switch, and
    # help message. This is later used to determine the optimal gutter width.
    help_parts = []
    for rule in self.help_rules:
      if (isinstance(rule, Option)):
        if (rule.short == "h"):
          help_switch = True
        elif (rule.long == "version"):
          version_switch = True
        help_parts.append(rule.getHelp())
      else:
        help_parts.append(rule)
        
    if ((not version_switch) and (self.version)):
      help_parts.insert(0, self.version_switch.getHelp())
    if (not help_switch):
      help_parts.insert(0, self.help_switch.getHelp())
    
    # Determine the optimal "gutter" size, where the switches are displayed.
    # There's a treshold at 25 columns: above this value, the help message is
    # simply displayed on the next line. So we're searching for the max width
    # below 23 chars (we need two spaces between switch and text).
    gutter_width = 0
    for part in help_parts:
      if (type(part) == types.ListType):
        if ((len(part[0]) < 23) and (len(part[0]) > gutter_width)):
          gutter_width = len(part[0])
    gutter_width += 2

    # Construct the help message
    help_msg = self.logger.chopNFill(self.getUsage(), term_width, 7, "usage:")
    help_msg += "\n"
    if (self.description):
      help_msg += self.logger.chopNFill(self.getDescription(), term_width)
      help_msg += "\n"
    
    if (type(help_parts[0]) == types.ListType):
      help_msg += "options:\n"
    for part in help_parts:
      if (type(part) == types.StringType):
        help_msg += self.logger.chopNFill(part, term_width)
      else:
        help_msg += self.logger.chopNFill(part[1], term_width, gutter_width, part[0] + "  ")
        
    return help_msg
    
  def getUsage(self):
    """ Return the usage string for the program, with %prog expanded. """
    usage = re.sub("(?<!%)%prog", sys.argv[0], self.usage)
    usage = usage.replace("%%prog", "%prog")
    return usage

  def getDescription(self):
    """ Return the usage string for the program, with %prog expanded. """
    
    if (self.description):
      description = re.sub("(?<!%)%prog", sys.argv[0], self.description)
      description = description.replace("%%prog", "%prog")
      return description
    else:
      return ""
    
  def addHelpRule(self, rule):
    """ Add a help message in between the help rules from the option
        definitions. """
    self.help_rules.append(rule)
    
  def addConflict(self, rule, message = "", action = "exit", **kwargs):
    """ Adds a clash rule and specify what to de when it is encountered. The
        rule itself consists of a string which can be evaluated with the vars
        know to the parser. The message is the error message displayed.
        There may be different actions taken:
        - "exit" means that the help message is displayed, followed by the
          message, and the program then exits.
        - "setvalues" means that the message is displayed and certain option
          values will be set. This needs to be specified in a dictionary named
          "vars".
        - "callback", means a callback is called. You need to specify the
          callback action with the "callback" var. It will receive the message
          and any other keyword arguments that you specify. """
        
    if (action == "exit"):
      # If the action is exit, the Conflict object needs to know where it can
      # get the help message.
      kwargs["help_getter"] = self.getHelp
    elif (action == "setvalues"):
      # If the action is setvalues, the Conflict object needs to know where it
      # can store the destination values.
      kwargs["destination"] = self.values
      
    self.conflicts.append(Conflict(self.logger, rule, message, action = action, **kwargs))
    
  def printAndExit(self, message, exit_code = 0):
    """ Prints the message to the terminal and exits with the specified exit
        code. """
        
    self.logger.message(message)
    sys.exit(exit_code)
    
  def printHelp(self, message = None, exit_code = 0):
    """ Prints the help message and exits with the specified exit code. """
    help_text = self.getHelp()
    if (message):
      help_text += self.logger.chopNFill("\n" + message, 80) # FIXME: terminal width is unknown
    
    self.printAndExit(help_text, exit_code)
    
  def printVersion(self):
    """ Print the version string and exits with specified exit code. """
    self.printAndExit(self.version, 0)
    
  def __getArg__(self, option, candidate):
    """ Helper method to convert a possible candidate argument to a real
        argument for the specified option, or return False if this cannot be
        done. """
        
    arg = None

    if (option.type == "string"):
      arg = candidate
    elif (option.type == "int"):
      try:
        arg = int(candidate)
      except ValueError:
        arg = None
    elif (option.type == "float"):
      try:
        arg = float(candidate)
      except ValueError:
        arg = None
    elif (option.type == "bool"):
      if (candidate.lower() in ["true", "yes", "y", "1"]):
        arg = True
      elif (candidate.lower() in ["false", "no", "n", "0"]):
        arg = False
      else:
        arg = None

    return arg
  
  def __isSwitch__(self, candidate):
    """ Tests whether the candidate string *looks* like a switch. It is
        intentionally not perfect, because the class need to warn a user when
        she made an error. """
    
    is_switch = False
    
    if (candidate[0] == "-"):
      if (candidate[1].isalpha()):
        is_switch = True
      elif (candidate[1] == "-"):
        if (candidate[2].isalpha()):
          is_switch = True
        
    return is_switch
        

